Skip to content

fix(client): preserve underlying status code in AutoDetect probe#1530

Open
MukundaKatta wants to merge 2 commits into
modelcontextprotocol:mainfrom
MukundaKatta:fix/autodetect-preserve-415-status
Open

fix(client): preserve underlying status code in AutoDetect probe#1530
MukundaKatta wants to merge 2 commits into
modelcontextprotocol:mainfrom
MukundaKatta:fix/autodetect-preserve-415-status

Conversation

@MukundaKatta

Copy link
Copy Markdown
Contributor

Why

HttpTransportMode.AutoDetect (the default) tries Streamable HTTP first and falls back to SSE on any non-success status. When Streamable HTTP returns 415 (e.g. wrong Content-Type, as GitHub Copilot's MCP endpoint does today) and the SSE fallback then fails — common against Streamable-HTTP-only servers that return 405 to the SSE GET — the SDK only surfaces the 405. The original 415 plus its body (the actual server diagnostic) is silently dropped.

Result: users debug the wrong protocol. They see "405 Method Not Allowed" against an endpoint they never explicitly tried to GET, with no hint that the real failure was a content-type mismatch on the Streamable HTTP attempt. Reported in #1526 with a reproducer against https://api.githubcopilot.com/mcp.

What

In AutoDetectingClientSessionTransport:

  • When the Streamable HTTP probe returns a non-success status, capture the status code and (best-effort, with a 5s read timeout and 1KB body cap, mirroring HttpResponseMessageExtensions.EnsureSuccessStatusCodeWithResponseBodyAsync) the response body, building an HttpRequestException via the existing shared helper.
  • Pass that captured error into the SSE fallback path. If the SSE attempt also fails (and isn't a cancellation), wrap both errors in an AggregateException whose first inner is the original Streamable HTTP error and whose second is the SSE failure. The user now sees the real server response without losing the fallback diagnostic.
  • Add a Warning-level log when the SSE fallback fails after Streamable HTTP also failed, so the dual-failure case is visible in logs even when callers swallow the exception.
  • Behavior on the success path (Streamable HTTP works, or SSE fallback works) is unchanged. Cancellation is preserved.

Tested

  • Added AutoDetectMode_PreservesOriginalError_WhenStreamableHttpReturns415AndSseFallbackFails in tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs. Drives the AutoDetect probe via SendMessageAsync (since ConnectAsync for AutoDetect only constructs the transport — the probe is lazy), simulates the exact 415→405 sequence from the issue with MockHttpHandler, and walks the exception chain to assert the original 415 status and response body reach the caller.
  • Existing AutoDetect tests (AutoDetectMode_UsesStreamableHttp_WhenServerSupportsIt, AutoDetectMode_FallsBackToSse_WhenStreamableHttpFails) untouched and still pass conceptually — neither relied on a specific exception shape from the dual-failure path.

Closes #1526.

…llback

When the Streamable HTTP probe fails with a non-success status (e.g. 415
Unsupported Media Type) the AutoDetect transport falls back to SSE. If
the SSE attempt also fails (e.g. a Streamable-HTTP-only server returns
405 to the GET), the SDK previously surfaced only the SSE error and
silently dropped the original Streamable HTTP response, masking the real
server diagnostic.

Capture the original status and response body up front and, when the SSE
fallback also fails, surface the original Streamable HTTP error as the
primary HttpRequestException (preserving its status code) with the SSE
failure attached as the inner exception. The user now sees the actual
server response without losing the fallback detail, and callers can still
catch HttpRequestException and read StatusCode. The dual-failure case is
also logged at Warning level.

Fixes modelcontextprotocol#1526.
…lback fails

Regression test for modelcontextprotocol#1526. Triggers the AutoDetect probe via
SendMessageAsync (ConnectAsync only constructs the transport) and verifies
that the original Streamable HTTP error and its response body are preserved
when the SSE fallback also fails. Adds coverage for the surfaced exception
shape (HttpRequestException with the SSE failure as inner), cancellation
propagation, and the Warning log emitted on the dual-failure path.
@tarekgh
tarekgh force-pushed the fix/autodetect-preserve-415-status branch from 3aae107 to 3724379 Compare July 20, 2026 22:10
// instead of dropping it on the floor (see https://github.com/modelcontextprotocol/csharp-sdk/issues/1526).
LogStreamableHttpFailed(_name, response.StatusCode);

var streamableHttpError = await HttpResponseMessageExtensions.CreateHttpRequestExceptionWithBodyAsync(response, cancellationToken).ConfigureAwait(false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading the body here is correct since it runs before the response is disposed. One thing worth a short comment: when the Streamable HTTP response is application/json but simply isn't a JSON-RPC error, TryReadJsonRpcErrorAsync above has already read the body once. HttpContent buffers after the first read so this second read returns the same buffered content and is safe, but a note would stop a future reader from mistaking it for a stream-consumption bug. For the common non-JSON error responses (415, 405, plain text) there is no double read because TryReadJsonRpcErrorAsync returns early on the content type.

#if NET
throw new HttpRequestException(streamableHttpError.Message, sseError, streamableHttpError.StatusCode);
#else
throw new HttpRequestException(streamableHttpError.Message, sseError);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On net472 this overload can't carry the status code, so callers on that target framework only get the status text in the exception message and not a programmatic StatusCode. That is acceptable given the framework limitation, but it means the preserved-status-code behavior is net5+ only. A one line note here would make it clear this is intentional rather than an oversight.

@tarekgh

tarekgh commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

One more thing: the PR description still describes wrapping both failures in an AggregateException. The current code surfaces the original Streamable HTTP HttpRequestException (status code preserved) with the SSE failure as the inner exception instead, so please update the description to match the final approach so reviewers aren't working from the old design.

@tarekgh

tarekgh commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

@MukundaKatta thanks for submitting this PR. I left a few minor comments. Could you please address it or let me know so I can help with it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AutoDetect transport hides the real error: surfaces 405 to the user when the server actually returned 415

3 participants